feat(wallet-sdk): accounts slice (step 6)#1167
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
Automated cross-model review — accounts slice (step 6)Scope: step-6 slice diff Verdict: well-built, faithful, well-tested slice. The projection-vs-domain split is applied consistently at every flipped call site, the fat-cache integrity design holds under adversarial attack (the MED —
|
|
MED addressed in 0eda60b — The five LOW items are held for the maintainer's disposition, per the review's framing — none are folded into this fix commit. |
Re-review — MED fix (
|
| ] = await withRetry({ | ||
| fn: () => | ||
| Promise.all([ | ||
| deps.keys.getEncryptionPublicKey(), |
There was a problem hiding this comment.
What is the cost of having this duplicated? I assume we are duplicating just for this interim phase until p18, and it wasn't deduped now for simplicity sake?
There was a problem hiding this comment.
It was simplicity — and your question tipped the balance the other way: deduped in 34479d8. ensure() now consumes the accounts namespace's repository through a getAccountRepository dep, so the instance has exactly one construction path (it also subsumes the createRepository test seam — the mapping test injects the same dep). Your derivation-retry now wraps the repository acquisition alongside the three key getters, so a transient failure retries all four async paths while the memoized getters keep the only-refetch-what-failed property.
There was a problem hiding this comment.
Context — that exchange predates the domain-types rebuild: at the time, ensure() constructed its own AccountRepository while the accounts namespace constructed another for sdk.accounts.*. The resolution was to dedupe: the accounts namespace owns the single construction path and ensure() consumes the same one through the getAccountRepository dep — which is what this line is. Nothing is duplicated today.
|
Post-review addition (maintainer-requested): the web now sources its key queries from the SDK's session keys instead of deriving its own copies — e0ea372 ( Net: the ~5 duplicate enclave derivations per session bootstrap drop to zero; one derivation authority; raw private-key bytes leave the query cache as a side effect. One declared deviation from the discussed sketch (smaller, same direction): Verified at the keeper gate: full chain re-run on the frozen tree (fix:all clean, 9/9 packages typecheck, tests exit 0 — wallet-sdk 99, web 36). |
|
On Why it currently is the way it is. A cashu account's balance is derived state — What changes under the expose-full-domain-types direction (the direction #1166 has now taken for the receive/send equivalents): the projection rationale collapses, and accounts can align cleanly — declare The call is yours + petar's, not mine — it reopens B1, which petar ruled explicitly for this slice. Two coherent end-states:
Sized and ready to execute either way on your word. |
|
Rebased onto the domain-types contract (d5d2f18 landing in #1166: domain What changed vs the previous head:
Verified at the keeper gate on the rebuilt head: fix:all clean, all packages typecheck, tests exit 0 (wallet-sdk 105, web 36); repo-wide grep confirms zero projection references remain. Independent delta review running. |
b5fb64f to
4bef09b
Compare
4bef09b to
d9a1fa2
Compare
Add createSessionKeys: per-session memoized getters for the encryption keypair, cashu seed, spark mnemonic, cashu locking xpub, and spark identity public key, derived from Open Secret. Each memo is generation-fenced so a derivation started before reset() cannot repopulate the cache for the next session, and rejections are not cached so a retry can recover. Wire keys.reset() into the AgicashSdk onSessionEnded teardown alongside the existing session-token, spark-wallet, and mint-auth-token clears, so a signed-in user's key material never survives into the next login. The accounts and user namespaces consume these getters in later commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement createAccountsApi: get(id), list() (session-gated for the userId), and cashu.add(params) over an internal AccountRepository built from the db, session keys, and spark config. The namespace returns the domain account types directly per the accounts contract (#1166) — no projection mapping. cashu.add re-injects type:'cashu' and the session userId before the service call, and list()/add gate on the session while get() relies on RLS (repository posture). getRepository is exposed alongside the api so the /temporary bridge and the user namespace's ensure() build the repository through one path. Pin AddCashuAccountParams to { name, mintUrl, currency, purpose } and wire the accounts field into AgicashSdk, replacing the not-implemented getter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expose getInternalAccountRepository through '@agicash/wallet-sdk/temporary': a module-scoped accessor that hands unmigrated receive/send flows and realtime row mapping the live instance's internal domain accounts repository, built through the same path as sdk.accounts.*. It throws 'No live AgicashSdk instance' before create and after dispose (the module reference is cleared on dispose alongside liveInstance), so the domain repository never leaks onto the public AgicashSdk surface. Removed at step 18 when those flows read from the SDK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK's user.ensure() needs the retry helper the web owned, so lift withRetry and delay from apps/web-wallet/app/lib into @agicash/utils (named exports, barrel re-exported) and delete the web copies. Flip the remaining consumers — the cashu receive-quote hook, the protected-route bootstrap, and the e2e Open Secret fixture — onto @agicash/utils, and add the workspace dependency to the e2e package. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add user.ensure(params) to the contract and implement it on the user namespace:
it derives the encryption public key, cashu locking xpub, and spark identity
public key, upserts the user row (creating the default accounts and persisting
the keys on first sign-in) through the base UpsertUserRepository, and returns
{ user, accounts } domain-typed for the host to seed its caches. The upsert
returns domain accounts already, so ensure returns them as-is — no mapping.
The key-derivation batch and the upsert each run under withRetry (matching the
resilience the host's query layer gave master); the upsert retry is Zod-aware so
a validation error fails fast. EnsureUserParams carries the replayed pending-terms
acceptance timestamps, distinct from acceptTerms' in-session boolean stamps.
ensure builds its repository through the accounts namespace's getRepository, so
the whole instance shares one construction path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flip the accounts data path onto the SDK namespace: accountsQueryOptions' queryFn calls sdk.accounts.list(), useAccountOrNull's lazy fetch calls sdk.accounts.get(id), useAddCashuAccount calls sdk.accounts.cashu.add (callers drop the now-implicit type: 'cashu'), and the realtime ACCOUNT_CREATED/UPDATED handlers map rows through getInternalAccountRepository().toAccount. The cache still holds domain accounts, so every consumer (getAccountBalance, wallet/proofs readers) is unchanged. account-service-hooks is deleted (the service lives behind sdk.accounts.cashu.add) and account-repository-hooks moves to features/receive, its only remaining consumers being the unmigrated cashu receive-quote and receive-swap repos. Extract getExtendedAccounts and isDefaultAccount from UserService into standalone pure functions exported from the package root, and flip their consumers (the accounts hooks, the claim route, and the SDK-internal claim service). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route _protected's bootstrap through sdk.user.ensure(): it replaces the inline key-derivation + upsert, seeds the user and accounts caches from the public return, and keeps master's structure (cache short-circuit, session-token warm, ensureBreezWasm placement, conditional seed on the upsert branch). Source the web-side key queries from the live instance's session keys via a new getInternalSessionKeys /temporary accessor: useEncryption collapses to a single ['encryption'] query over getEncryption(), and the cashu-seed and spark-mnemonic query fns delegate to the same getters. The per-key encryption query options and their hooks are deleted (only encryption-hooks and the two route warms read them), sparkIdentityPublicKeyQueryOptions is dropped (its only reader was the ensure warm), and xpubQueryOptions is untouched. The bootstrap warms encryption, seed, and mnemonic concurrently with ensure() so the unmigrated receive/send/claim repos keep master's warm-cache and fail-in-the-middleware behavior. The web defaultAccounts copy is removed now that ensure() owns it SDK-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bring the step-6 decision record onto the branch and append a supersession entry: the maintainer's contract-level domain-types ruling (d5d2f18, #1166) superseded B1's projection apparatus after the slice was first built, so sdk.accounts.* returns domain types directly (no mapper, no checked cast), B5 balance reads revert to getAccountBalance, and the runtime-fat reality-class record and the step-18 physical strip retire as moot. The record's prior entries stand as the decision trail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
d9a1fa2 to
0a34e6c
Compare
|
Rebased onto master following the #1166 merge and retargeted the PR (base:
Next: sweeping all #1166 review comments for reasoning applicable to this slice, per @petar; will report findings here. |
… scope Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#1166 review-comment sweep — applicability to this PRPer @petar's ask: enumerated all #1166 commentary (55/55 review threads, 192 inline comments, 3 issue comments — pagination exhausted, counts cross-checked against the REST comment total) and audited this PR's full diff against every ruling that generalizes beyond the auth slice. Applied (2 new commits)
Verification on the pushed head: lint + typecheck clean, wallet-sdk 105/105, web-wallet 36/36. Already satisfied (verified against the diff, no change needed)
Held as questions (parity vs. ruling — your call, no code changed)
|
There was a problem hiding this comment.
Why is this file on the top level and not in one of the folders?
There was a problem hiding this comment.
It's shared plumbing with no single owner: the user namespace (ensure() derives the public keys), the accounts namespace (the repository takes the seed/mnemonic getters), and the SDK root (construction, reset on session end) all consume it, so any domain folder would force cross-domain imports. That said, it's the only non-config file at the top level — it can move to a shared folder (e.g. lib/) in the next code round if you prefer.
| userService.setDefaultAccount(requireUserId(), params.account, { | ||
| setDefaultCurrency: params.setDefaultCurrency, | ||
| }), | ||
| ensure: async (params) => { |
There was a problem hiding this comment.
This naming is weird. Ensure what?
There was a problem hiding this comment.
The full call reads sdk.user.ensure(...) — meant as "ensure the user" — but agreed it's ambiguous, especially at the implementation site. What it ensures: the signed-in auth user has a wallet user row and default accounts — creates them on first sign-in, updates when auth data changed, no-ops otherwise. Renaming per the other thread.
| giftCardMintTermsAcceptedAt: params.giftCardMintTermsAcceptedAt, | ||
| }), | ||
| retry: (attemptIndex, error) => { | ||
| if (error instanceof core.$ZodError) { |
There was a problem hiding this comment.
What is this check for? Did we have that before?
There was a problem hiding this comment.
Yes — ported from master: the same predicate lives in master's _protected.tsx retry (line 143 there). A Zod validation failure means the upsert response didn't match the expected schema — that's deterministic, retrying can't change it, so we bail; anything else retries up to two more attempts.
| ] = await withRetry({ | ||
| fn: () => | ||
| Promise.all([ | ||
| deps.keys.getEncryptionPublicKey(), |
| /** | ||
| * Returns true if the account is the user's default account for its currency. | ||
| */ | ||
| export function isDefaultAccount( |
There was a problem hiding this comment.
Two changes here: (1) isDefaultAccount/getExtendedAccounts moved from statics on UserService to standalone exported functions — the SDK contract exposes these two helpers to the host while the UserService class stays internal, and exporting the class just to reach two statics would put the whole service on the public surface. (2) The user parameter narrowed from User to a Pick of the two default-account ids, because that's all these functions read — callers don't need a full User to use them. Behavior is unchanged from the master statics.
| private_key_derivation_path: encryptionKeyDerivationPath, | ||
| }).then((response) => response.public_key); | ||
|
|
||
| export function createSessionKeys(deps: SessionKeysDeps = {}): SessionKeys { |
There was a problem hiding this comment.
Why do we need to cache things here? Should this be cached in the web app (clients)?
There was a problem hiding this comment.
It's per-session dedupe rather than app-level caching: the SDK's own internals hit these getters repeatedly — ensure() derives three public keys, and every repository construction takes the seed/mnemonic getters — and each underlying call is a round trip to the Open Secret enclave. The memo makes each derivation happen once per session; reset() drops it at session end. The web still caches for its own consumers in the query layer, but those queryFns delegate to these getters, so the enclave work happens once either way. Before this PR the web's five bootstrap key queries each derived independently — same keys, five enclave round trips.
| // repository on the public AgicashSdk surface. | ||
| let liveAccountRepository: (() => Promise<AccountRepository>) | undefined; | ||
|
|
||
| // The live instance's session keys, reached only through the |
There was a problem hiding this comment.
What does live mean here? Do we have something else that is not live?
There was a problem hiding this comment.
"Live" = the instance currently constructed and not yet disposed. There's no second kind of instance — the word distinguishes the current one from a disposed (or replaced) one across the construct→dispose lifecycle: the module-scoped slot is set on construction, cleared on dispose, and the accessors throw when nothing is live. Happy to rename to current/active if that reads better.
| // the same derivation ensure() runs SDK-side. Removing a warm before its | ||
| // domain migrates into the SDK breaks that domain's bootstrap. | ||
| const [{ user: upsertedUser, accounts }] = await Promise.all([ | ||
| sdk.user.ensure({ |
There was a problem hiding this comment.
This seems odd now. The code checks if the data on the supabase and OS user is different and if yes calls ensure which doesn't even take that data in
There was a problem hiding this comment.
That's master's own short-circuit, kept as-is (master _protected.tsx:90: if (!user || hasUserChanged(user, authUser)) gating the inline upsert). It only decides WHETHER to call — skip the bootstrap when the cached wallet user already matches the auth user. ensure() doesn't need the compared data passed in because it reads the auth user from the SDK's own session and derives everything else internally; the web comparison supplies no inputs, it just avoids an unnecessary round trip on remounts.
| queryClient.ensureQueryData( | ||
| sparkIdentityPublicKeyQueryOptions({ queryClient, network: 'MAINNET' }), | ||
| ), | ||
| // These warms populate the cache entries that the receive/send/claim |
There was a problem hiding this comment.
Warms? I have no idea what this comment is saying
There was a problem hiding this comment.
Fair hit — "warms" is prefetch jargon; will reword to "prefetches" in the next code round. What it's saying: these ensureQueryData calls prefetch the cache entries (encryption / seed / mnemonic) that receive, send, and claim code — not yet migrated into the SDK — still reads at bootstrap. Removing one before its feature migrates breaks that feature's startup.
| termsAcceptedAt, | ||
| giftCardMintTermsAcceptedAt, | ||
| }), | ||
| queryClient.ensureQueryData(encryptionQueryOptions()), |
There was a problem hiding this comment.
So now some stuff is cached here some in the sdk? Seems like encryption keys are cached on both places so not sure I follow what is going on
There was a problem hiding this comment.
Close, but the split is deliberate: raw key material is memoized only in the SDK (once per session, dropped at session end). The web query layer caches the consumer-facing objects built FROM it — ['encryption'] holds the Encryption helper, not private-key bytes — and its queryFns delegate to the SDK getters, so the derivation itself happens once, SDK-side. Encryption keys are no longer cached in two places: before this PR the web cache held the raw private-key bytes; that entry is deleted. That was the point of the dedupe round — one derivation authority, raw bytes out of the query cache.
Step 6 of the wallet-SDK migration (
docs/superpowers/specs/2026-06-24-wallet-sdk-no-cache-production-design.md): wrap the accounts domain inAccountsApiand flip the web's accounts data layer ontosdk.accounts.*. Plan with the full Decision Record (B1–B7, maintainer-resolved) and gate record:docs/superpowers/plans/2026-07-13-wallet-sdk-accounts-slice.md.Stacked on
sdk/auth-slice(#1166). After #1166 merges: rebase onto master, retarget, re-verify (two-green-PRs rule).What lands
session-keys.ts): memoized, generation-fenced getters over Open Secret (encryption keypair, cashu seed, spark mnemonic, locking xpub, spark identity pubkey), cleared inonSessionEnded— a different user can never be served the previous user's keys.sdk.accounts(get/list/cashu.add) over one shared domain→projection mapper;AddCashuAccountParamspinned ({ name, mintUrl, currency, purpose });AgicashSdkPickgrows'accounts'.['accounts']cache holds projection-typed objects fetched viasdk.accounts.list(); hidden domain fields ride along at runtime until step 18 (type-level strip now, physical then). The shared mapper is the only cache entry point (queryFn, realtime, add onSuccess, ensure seed, claim upserts). Reality-class:sdk.accounts.*is TYPE-honest / RUNTIME-fat-until-18, intended and time-boxed; assumes web-internal consumers only during the window./temporarybridge v2: internal-repo accessor +toDomainAccount()checked cast (throwsMissingDomainFieldsErrornaming missing fields — never a bare cast) + mapper re-export. All carry step-18 removal notes.sdk.user.ensure(): the_protected.tsxbootstrap ported verbatim (key derivation, default accounts, Zod-aware retry, session-fenced memo); returns{ user, accounts }projection-typed. Timestamp params are replayed acceptance times from pending-terms storage (maintainer-confirmed intent, JSDoc'd). Web glue keeps master's structure exactly: short-circuit, prefetch warm-up, conditional seeding.Account/CashuAccount/SparkAccount/ExtendedAccountare now the projections (domain types import from/temporary); display reads.balanceoff the cache; money flows stay domain end-to-end through sanctioned unwrap seams.Unwrap seams (every hidden-field read passes
toDomainAccount())account-hooks: the four getter hooks unwrap internally;useDefaultAccount/useAccountOrDefaultreturn domain.buy-input/send-input/receive-input: unwrap at the selector option build;account-selectorrenders domain viagetAccountBalance(master verbatim).send-providergetAccounts; routes_protected.buy.checkout/receive.cashu/receive.spark/transfer.$destinationAccountId/send; claim route.account-proofs,transaction-additional-details,use-track-spark-account-balances(spark listener) at their seam.receive-cashu-token-hooks: unwraps forgetSourceAndDestinationAccounts; maps the domain source account back through the shared mapper for display items.Declared (questions and transitional items, not silent changes)
accounts.get(id)is not session-gated (master's repository posture — RLS scopes it);list/addgate on the session foruserId,user.*gates every verb. Parity kept; consistency call open to review.withRetry/delayported into the SDK lib; web copies remain for unmigrated consumers (dies with their slices).account-repository-hooksrelocated tofeatures/receive/(not deleted): its only consumers are unmigrated receive repos constructingAccountRepositorysynchronously; reshaping them is step 8–16 scope.account-service-hooksdeleted.session-keys.tsports master's hardcoded MAINNET for the spark identity pubkey (master's own TODO).sparkDebugLogstays a named/temporaryexception (maintainer ruling); dies at 18 with its call site.Verification
fix:allclean · workspace typecheck green (all 9 packages incl. web) · tests exit 0: wallet-sdk 96 (25 new: projection completeness type+runtime, mapper single-entry, checked-cast thin-object throw, ensure memo/retry, key fencing across session end), web 36, money 14, bolt11 9, ecies 18, cashu 35. Chain re-run independently at the keeper gate on the frozen tree.🤖 Generated with Claude Code